Skip to content

tasks: stop task_create from overwriting the task it collides with - #286

Open
polyglotAI-bot wants to merge 1 commit into
ClickHouse:mainfrom
polyglotAI-bot:polyglot/task-id-collision
Open

tasks: stop task_create from overwriting the task it collides with#286
polyglotAI-bot wants to merge 1 commit into
ClickHouse:mainfrom
polyglotAI-bot:polyglot/task-id-collision

Conversation

@polyglotAI-bot

Copy link
Copy Markdown
Contributor

Problem

A task ID is <date>-<slug>, where the slug is the title lowercased, dashed and
truncated to 40 characters (handlers/tasks.py:_make_task_id). Two titles that
differ only past that cut therefore produce the same ID on the same day — and
task_create wrote it out regardless:

  • file_path.write_text(...) truncated the older task's markdown to the new body,
    taking its context and every ## Updates note with it;
  • upsert_task(task_id=..., title=..., status=..., content=...) then rewrote that
    same row
    ON CONFLICT(id) DO UPDATE — with the new task's title, status and
    content.

Both copies of the older task were gone, the tool answered Task created: <id>, and
nothing logged a word. Hit in production: an in-progress task lost its body, its claim
note and its findings to a follow-up task whose title shared a long prefix.

The duplicate check ahead of it does not cover this. It reports semantically similar
tasks and tells the caller to retry with confirm_duplicate=true — which was the one
flag that skipped the report and went straight into the overwrite. So confirm_duplicate
meant "overwrite", not "create a second task". Two IDs were reachable this way that were
not even similar-looking: a completed task's ID (its row still holds it while its file
sits in done/, so the new task took the row and orphaned the markdown), and the base
<date>- that every punctuation-only title shares.

Fix

_claim_task_id walks base, base-2, base-3, … and takes the first ID that is free
on all three counts, then hands back the ID it actually claimed:

Check Catches
no row holds the ID index is authoritative for a task whose file is gone or lives elsewhere; upsert_task would otherwise rewrite that row in place
no done/<id>.md a completed task's ID stays reserved, so it is never resurrected as a new pending one — and TaskManager.reindex can never see one stem in both directories
open(path, "x") succeeds in active/ untracked leftovers, and the window between the free-ID check and the write that two concurrent creators share

O_EXCL is what makes the last one atomic: the loser of a race gets FileExistsError and
moves to the next suffix. Past 99 suffixes the create is refused with is_error=True
instead of resolved. An upsert_task that fails after the file lands unlinks it again,
so a claimed ID is not burned by a half-done create — the retry keeps the ID it was
reaching for.

Nothing else in the repo reconstructs an ID from a title (_make_task_id has exactly one
caller), and IDs are opaque keys everywhere else — nothing parses the date prefix — so a
-2 suffix is inert to every consumer. The same handler backs both the MCP tool and
POST /api/tasks, so both paths get the fix.

One deliberate non-change: POST /api/tasks returns the tool result as HTTP 200 even when
is_error is set. This adds the first is_error that route can emit, but no route in the
repo maps that field to a status code, so introducing 409 here would be a REST-contract
change belonging in its own PR.

Verification

16 new tests in tests/test_task_id_collision.py, one behaviour each: 15 fail without
the fix
, and the 16th is the control pinning that non-colliding titles keep their
unsuffixed IDs. Full suite 3030 passed (base 3014 + 16).

Covered: the older task's file, row and FTS content survive; the response names the
claimed ID; suffixes keep walking to -3; completed IDs stay reserved (both with and
without a row); a row whose file is gone still holds its ID; a rival file appearing
inside the check window is not overwritten; two asyncio.gathered creates land on two
tasks; exhaustion refuses and writes nothing; a failed index write gives the ID back; and
the degenerate cases — creating straight into done, a title whose own base is another
task's -2, punctuation-only titles, and ctx.db is None.

Seven mutants, each weakening one clause, are all killed — including replacing the
exclusive create with a preceding path.exists() check, which only the in-window rival
test catches.

A task ID is the date plus the title slugified and truncated to 40
characters, so two titles that differ only past that cut share one ID.
task_create wrote it out regardless: write_text truncated the older
task's markdown to the new body, and upsert_task rewrote that same row's
title, status and content. Both copies of the older task were gone, and
the tool still answered "Task created".

The duplicate check does not cover this. It reports semantically similar
tasks and offers confirm_duplicate=true, which was the one flag that
skipped past the report straight into the overwrite -- so confirming a
duplicate meant overwrite rather than "create a second task".

_claim_task_id now walks base, base-2, base-3, ... and takes the first ID
free of a row, of a done/ file, and of an exclusive create in active/.
The row check keeps a new task from inheriting an existing one's history,
the file checks hold when index and tree disagree, and O_EXCL closes the
window between the check and the write. done/ IDs stay reserved, so a
finished task is never resurrected as a new pending one. With every
candidate taken the create is refused with is_error instead of resolved,
and an upsert that fails after the file lands drops it again so the retry
keeps the ID it was reaching for.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@polyglotAI-bot
polyglotAI-bot force-pushed the polyglot/task-id-collision branch from 6ed626c to 69fc0a4 Compare August 10, 2026 14:31
@polyglotAI-bot

Copy link
Copy Markdown
Contributor Author

Rebased onto main (69fc0a4) — was conflicting.

The conflict was in nerve/agent/tools/handlers/tasks.py, and both sides needed to survive rather than one winning:

  • _emit_task_event (added upstream) and the ID-claiming helpers (_claim_task_id, _write_new_file, _MAX_TASK_ID_SUFFIX) landed in the same place — kept both.
  • The upsert_task call gained actor=ctx.session_id upstream, while this PR wrapped it in a try/except that unlinks the freshly-claimed file if the upsert fails. The resolution keeps the wrapper and passes actor=, so neither the actor attribution nor the burned-ID cleanup is lost.

docs/tasks.md merged without conflict alongside #8172318's changes.

Verification after the rebase:

  • tests/test_task_id_collision.py16 passed
  • All task-related tests (-k task) — 305 passed
  • Regression proof: reverting only the handler leaves 15 failed, 1 passed
  • Full suite — 3206 passed

Worth flagging for review scope: this PR fixes the collision in the agent tool handler. POST /api/tasks in nerve/gateway/routes/tasks.py is a separate creation path, and I have not checked whether it shares the same overwrite behaviour — happy to look if you want it in this PR or a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant